> Smash the Stack for Fun and Profit (ed.
Phrack #49)
>
Buffer Overflows (ed. Oregon Graduate Institute of Science and
Technology)
| I/ Introduction En quelques mois les buffer overflow on
beaucoup attirer l'attention, c'est rapidement devenu incontournable dès
que l'on parle de "hacking". On note des centaines d'exemple comme
sendmail, mount (FreeBSD, Linux), des librairies... Il faut avoir un minimum de bases en assembleur, et
en C . Il faut aussi comprendre les concept de base de la mémoire, et une
expérience avec gbd (un débuggeur de Linux) est la bienvenue. Avant de commencer, il vous faut savoir
ce qu'est un buffer. Un buffer est une suite de block de la mémoire d'un
ordinateur qui contient plus informations, de mêmes types. Les
programmeurs en C, l'associerons sûrement avec l'instruction Buffer. Ces
buffers, comme toutes les variables en C, peut être déclarée statique ou
dynamique. II/ L'organisation de la mémoire Avant d'essayer de faire son buffer
overflow, il faut savoir comment votre Pc gère sa mémoire. Un processus
est divisé en 3 régions: text, data, et stack. Nous nous concentrerons
essentiellement sur la dernière région, mais savoir ce que sont les autres
régions ne nous fera pas de mal. La région text est définie par le
programme et son code. Cette région est normalement en read-only et quand
on essayera d'écrire dessus on aura une segmentation fault. Un petit schéma
: /------------------\
lower III/ Qu'est ce qu'une stack ?
Une stack est une
notion abstraite couramment utilisée en informatique. Une stack à la
propriété que le dernier objet placé à l'intérieur sera le premier
enlevé. IV/ Pourquoi utilisons nous les stacks ?
Les PC modernes sont fait pour utiliser
les meilleurs langages. Dans ces langages le plus important est la
structure et pour cela on utilise une procédure ou fonction. Une stack est
utilisée pour localiser dynamiquement les variables locales utilisées dans
les fonctions, et retourner à cette dernière des valeurs.
noyau au lancement du programme. La première chose qu'un processus doit
faire quand il est appelé est de sauvegarder le FP antérieur (pour pouvoir
le restaurer à la fin). Maintenant voyons à
quoi une stack ressemble grâce à un code simple example1.c void main()
{ Pour comprendre ce que le programme fait pour appeler la fonction (), on le compile en utilisant ggc et -S pour générer le code assembleur : $ gcc -S -o example1.s example1.c
En
regardant le code de l'assembleur on remarque que l'appel de la fonction
est devenu : pushl
$3 Il utilise Push pour pousser 3 argument
à la fin de la stack et apres appelle la fonction. L'instruction "call"
pousse le pointeur d'instruction (IP) sur le stack. On appelera l'IP sauvé
l'adresse de retour (RET). La premiere chose faite dans l'instruction est
est la procédure de prolog : pushl %ebp Cela pousse EBP , le FP, sur le stack.
Il copie donc le SP dans EBP, le faisant devenir le nouveau FP pointeur.
Nous appellerons le nouveau FP SFP. Cela crée donc de la place pour les
variables locales. Nous devons aussi penser que la mémoire
ne peut être réservée que par multiple du mot. Je m'explique. Si on veut
un buffer de 5 bytes et que notre "mot" est de 4 bytes alors le buffer
prendra 8 bytes (2 mots) de mémoire. Il faut aussi garder en mémoire que
notre stack ressemble à cela lorsque la fonction () est appelée (chaque
espace représente un byte) : bottom of top
of top of bottom
of V/
Buffer overflow Un buffer overflow n'est que le
résultat d'une action. Cela arrive quand on essaye de mettre plus
d'information qu'il n'y a de place dans un buffer. Comment peut on tirer
un quelconque avantage de cette faute de programmation? Regardez l'exemple
suivant :
example2.c strcpy(buffer,str); void main()
{ for( i = 0; i
< 255; i++) function(large_string); Ce programme a une fonction, qui est
une typique faute de programmation, un typique buffer overflow. La
fonction copie une chaîne (string) sans vérifier les limites en utilisant
strcpy() à la place de strncpy(). Si vous essayer de faire tourner ce
programme, vous aurez une faute de segmentation. bottom of top
of memory memory top of bottom
of On va expliquer ce qu'il se passe
pendant l'exécution du programme. Strcpy () copie le contenu de *str (qui
point vers larger_string) dans le buffer. Problème, le buffer fais 16
bytes de long, alors de larger_string en contient 256.... Ca signifie
qu'il y a 250 bytes de trop, et donc que 250 bytes de la stck serons
réécrites (overwrite : réécrite de force). Cela inclue SFP (le nouveau FP
pour ceux qui on la mémoire courte) RET et même *str.... Larger_string a
été remplie avec des 'A'. 'A' en hexadécimal c'est 0x41. Cela veut donc
dire que notre adresse de retour est maintenant x41414141. C'est en dehors
de la réserve de mémoire allouer au processus. C'est pour ça que quand
votre fonction reviens et essaye de lire l'instruction suivante à cette
adresse vous avez une faute de segmentation. Un buffer overflow nous permet donc de
changer l'adresse de retour d'une fonction. On pourra donc changer le flot
d'instruction d'un programme. Revenons à notre premier programme et
rappelons l'allure de la stack : bottom of top
of top of bottom
of On va essayer de
modifier notre premier programme pour qu'il réécrive l'addresse de retour,
et on va démontrer comment on pourra en faire un code arbitraire. Dans la
stack juste avant le buffer1[ ] on voit le SFP, et avant l'adresse de
retour. C'est 4 bytes après la fin du premier buffer. Mais il faut se
rappeler que le buffer fait deux mots, donc 8 bytes, donc l'adresse de
retour est 12 bytes après le début du premier buffer. example3.c ret = buffer1 + 12; void main() { x = 0; Ce que l'on a fait c'est ajouter 12
bytes à l'adresse du buffer1[ ]. Cette nouvelle adresse et l'emplacement
ou l'adresse de retour est stockée. On voudrai faire sauter l'affectation
vers l'appel de printf(). Comment peut on ajouter 8 à l'adresse de retour
? Pour cela on va compiler le programme et le passer au débuggeur.
[Acid]$ gdb
example3 On peut voir qu'a
l'appel de la fonction () le ret sera 0x8004a8, et nous voudriez sauter
l'allocation 0x8004ab. La prochaine instruction que l'on veut exécuter est
celle au 0x8004b2. Un petit calcul nous dit qu'il y a 8 bytes entre les
deux. VI/ les shells Maintenant, on sait
modifier l'adresse de retour et le flot d'instruction, quel programme
voulons nous exécuter ? Dans la plus part des cas on veut un shell. Depuis
le shell on pourra alors exécuter les commandes que l'on veut. Mais
comment faire s'il n'y a pas de tel programme dans notre exploit? Comment
faire pour mettre des instructions arbitraire dans les adresses? La
solution est de placer le code que l'on veut executer dans le buffer que
l'on overflow et de réécrire sur le ret comme ça, ça repointe vers le
buffer. Présumons que la stack commence à l'adresse 0xFF, et que S
remplace le code que l'on veut exécuter. La stack ressemblerai alors à
ceci : bottom of
DDDDDDDDEEEEEEEEEEEE EEEE FFFF FFFF FFFF FFFF top of <------
[SSSSSSSSSSSSSSSSSSSS][SSSS][0xD8][0x01][0x02][0x03] Le code pour nous donner un shell
ressemble à ceci shellcode.c #include
<stdio.h> name[0] =
"/bin/sh"; Pour voir à quoi cela ressemble en
assembleur on le compile et on redémarre GBD. Ne pas oublier d'utiliser
-static. Sinon le système execve ne sera pas pris en compte, à la place on
aura une référence à la librairie C qui est normalement chargée au
démarrage du programme. [Acid]$ gcc -o shellcode -ggdb -static
shellcode.c On va essayer de comprendre ce qu'il ce
passe. On commence par main 0x8000130
<main>: pushl %ebp Ca c'est le prélude de la procédure.
Tout d'abord il sauve l'ancien pointeur, fait du SP ne nouveau FP et
libère de la place pour les variables locales. Dans ce
cas : char *name [2] ;
ou deux pointeur pour une instruction
char. Les pointeurs sont des mots long, cela libère donc de la place pour
deux mots (8 bytes). 0x8000136
<main+6>: movl $0x80027b8,0xfffffff8(%ebp) On va copier la valeur 0x80027b8
(l'adresse de '/bin/sh') dans le premier pointeur de name
[]. name [0] =
"/bin/sh" ; 0x800013d
<main+13>: movl $0x0,0xfffffffc(%ebp)
On copie la valeur 0x0 (NULL) dans le
deuxième pointeur de name []. name [1] = NULL ;
L'appel d'execve () commence ici.
0x8000144
<main+20>: pushl $0x0 On fait un push sur l'execve () pour
renverser l'ordre de la stack. On commence alors par NULL.
0x8000146
<main+22>: leal 0xfffffff8(%ebp),%eax On charge l'adresse de name [] dans le
registre EAX. 0x8000149
<main+25>: pushl %eax On fait un push sur l'adresse de name [ ] sur la
stack 0x800014a
<main+26>: movl 0xfffffff8(%ebp),%eax On charge l'adresse de /bin/sh dans le
registre EAX. 0x800014d
<main+29>: pushl %eax On fait un push sur l'adresse de
/bin/sh sur la stack.
0x800014e
<main+30>: call 0x80002bc <__execve> Ceci appelle la fonction librairie
execve (). L'appel de l'instruction fait un Push sur l'IP dans la stack.
Maintenant execve (). Gardez à l'esprit
que nous utilisons, un Intel avec Linux. Les détails de l'appel change en
fonction du processeur, mais aussi de l'OS. Certains mettrons les
arguments dans la stack, d'autre dans les registres. Certains utilisent un
interruption pour passer en mode kernel, d'autres utilisent un appel
lointain. Linux passe ses arguments pour l'appel système dans les
registres et utilise une interruption pour passer en mode kernel.
0x80002bc <__execve>: pushl
%ebp Le prélude de la
procédure... 0x80002c0
<__execve+4>: movl $0xb,%eax Copie 0xb (11 en decimal) dans la
stack. C'est l'index dans la table des appels. 11 est
execve 0x80002c5 <__execve+9>: movl
0x8(%ebp),%ebx Copie l'adresse de /bin/sh dans EBX 0x80002c8
<__execve+12>: movl 0xc(%ebp),%ecx Copie l'adresse de
name [] dans ECX 0x80002cb
<__execve+15>: movl 0x10(%ebp),%edx
Copie l'adresse du pointeur NULL dans
EDX 0x80002ce <__execve+18>: int
$0x80 Passe en mode kernel Donc comme on peut le voir on ne doit
pas beaucoup à execve (). Tout ce dont on a besoin c'est : Mais que ce passerai t il si l'appel
d'execve ( ) lâchait pour quelques raisons ? Le programme continuerai a
aller chercher des instructions dans la stack qui contient qui contient un
nombre aléatoire de données ! Ce programme ferai la plus part du temps un
core dump. Nous voudrions que le programme se termine sans problème si
l'appel système d'execve ratai. Pour cela on va rajouter un appel système
de sortie ('exit') après celui d'execve. A quoi ressemble un appel de
sortie ? exit.c exit(0); } /*----------------------------------- Cut here ---------------------------------*/ [Acid]$ gcc -o exit
-static exit.c L'appel système 'exit' va mettre 0x1
dans l'EAX, et met le code de sortie dans l'EBX, et exécute "int 0x80". La
plus part des applications retournent 0 lors de la sortie pour indiquer
qu'il n'y a pas eu d'erreur. On aura donc un 0 dans EBX. Notre liste
d'étape est maintenant : - Avoir quelques part dans la mémoire
la chaine /bin/sh terminée par null movl
string_addr,string_addr_addr Le problème est que nous ne savons où
dans la mémoire le programme que nous essayons d'exploiter (ainsi que la
chaîne qui le suit) sera placé. La seule façon de contourner se problème
et d'utiliser une instruction jump et une instruction call. Le JMP et le
Call peuvent utiliser adresse relative ip, ce qui veut dire qu'on peut
sauter vers une adresse sans savoir sa valeur. Si nous plaçons une
instructions call juste devant la chaîne "/bin/sh " et que l'on fait un
jump vers celle ci, l'adresse de la chaîne sera poussée dans la stack
quand call sera exécutée. Tout ce que nous avons a faire est de copier
l'adresse de retour dans un registre. L'instruction CALL peut simplement
appeler le début de notre code. Admettons que J siot l'instruction jump, C
soit call, et s la chaîne. Le flots d'instruction sera de la forme
suivante : bottom of
DDDDDDDDEEEEEEEEEEEE EEEE FFFF FFFF FFFF FFFF top of <------
[JJSSSSSSSSSSSSSSCCss][ssss][0xD8][0x01][0x02][0x03] Avec ces modifications, en utilisant
une adresse indexée, et mettant la taille de chaque instruction notre code
deviens :
jmp offset-to-call # 2 bytes En calculant les déplacements de jmp à
call, de call à popl, des chaînes d'adresse aux tableaux, etc. on obtient
: jmp 0x26 # 2 bytes Ca a l'air pas mal. Pour être sur qu'il marche
normalement, il faut le compiler et le démarrer. Mais il y a un problème,
notre code se modifie lui même, mais la plus part des OS marquent les
codes en read-only. Pour éviter cette restriction nous devrons le placer
dans une stack, ou un segment d'informations et lui transférer le
contrôle. Mais nous devons d'abord avoir une représentation hexadécimale
du code. Compilons le et après
utilisons gdb pour le faire. shellcodeasm.c void
main() { [Acid]$ gcc -o
shellcodeasm -g -ggdb shellcodeasm.c testsc.c void main()
{ ret = (int
*)&ret + 2; } [Acid]$ gcc
-o testsc testsc.c Ca fonctionne ! Mais il reste un
obstacle. Dans la plupart des cas on va essayer de faire un overflow sur
un buffer de caractère. Donc, dès qu'il y aura un byte null dans notre
code il sera considéré comme la fin de la chaîne et le copiage se
terminera. Il ne doit donc pas y avoir de byte null dans notre code pour
que l'exploit marche. Essayons d'éliminer les bytes null et de le faire
plus petit. Problem instruction: Substitute
with: shellcodeasm2.c Notre nouveau
programme de test testsc2.c void main() { ret = (int *)&ret + 2; }
/*----------------------------------
Cut here ----------------------------------*/ VII/ Comment écrire un exploit Mettons toutes nos
pièces ensemble. On a un shell code. On sait qu'il doit faire partie de la
chaîne que l'on utilisera pour faire l'overflow du buffer. On sait qu'on
doit pointer l'adresse de retour dans le buffer. overflow1.c char
large_string[128]; void main()
{ for (i = 0; i
< 32; i++) for (i = 0; i
< strlen(shellcode); i++) strcpy(buffer,large_string); /*-----------------------------------
Cut here ---------------------------------*/ Ce que nous avons fait ici c'est remplir
le tableau large_string[] avec l'adresse du buffer [] où sera situé notre
code. Après on copie le shell code au début de la chaîne large_string.
strcpy () va alors copier large_string dans le buffer [] sans faire
attention aux limites, et va donc faire un overflow sur l'adresse de
retour, à la place il va mettre l'adresse de notre code. Dès qu'il a fini,
il fait un jump vers l'adresse de retour qui pointe vers notre code, qui
exécute un shell. Mais
on rencontre un petit problème quand on essaye de faire un buffer overflow
c'est qu'un autre programme est peut être là ou devrait être notre buffer
(et donc, notre code). La solution est que pour tous les programmes la
stack démarre au même endroit. La plupart des programmes ne pousses pas de
plus de quelques dizaines, voir quelques centaines de bytes dans la stack
en une fois. Donc en sachant ou commence la stack, on peut deviner où est
le buffer sur lequel on essaye un overflow. Voici un petit programme qui
nous montre ou est le stack pointer
sp.c /*---------------------------------- Cut here
----------------------------------*/ Admettons que le programme sur lequel on tente
l’overflow vulnerable.c void main(int argc, char *argv[])
{ if (argc >
1) Maintenant on peut
essayer de faire un programme qui prends comme paramètres la taille du
buffer, et un offset de son propre SP. Nous mettrons l'overflow dans un
environnement variable, comme cela ce sera facile a manipuler.
exploit2.c
#include
<stdlib.h> #define
DEFAULT_OFFSET 0 char
shellcode[]
= unsigned long
get_sp(void) { void main(int
argc, char *argv[]) { if (argc >
1) bsize = atoi(argv[1]); if (!(buff =
malloc(bsize))) { addr =
get_sp() - offset; ptr =
buff; ptr +=
4; buff[bsize -
1] = '\0'; memcpy(buff,"EGG=",4); Maintenant il faut essayer de deviner
ce que sont le buffer et l'offset [Acid]$ ./exploit2 500 Comme on peut le voir deviner l'offset
c'est pas super, voir presque impossible. Il faut au moins des centaines
d'essais, voir même des milliers... Le problème est que l'on ne connaît
pas exactement où l'adresse de notre code va commencer. Si on se trompe
d'un byte de plus ou de moins on va avoir une faute de segmentation ou une
instruction non valide. Un moyen d'augmenter nos chance est de mettre
avant notre buffer overflow des instructions NOP. Tous les processeurs ont
une instruction NOP qui fait une opération null. On va sent servir, et
remplir la moitié de notre buffer avec. On met notre shell code au milieu
et après on le fait suivre par l'adresse de retour. Si on est chanceux, il
pointera dans les NOP qui seront exécutés jusqu'a ce qu'il rencontre notre
shell code. Dans l'architecture Intel les NOP sont long d'un byte et dans
le langage machine correspond à 0x90. Voici l'allure de notre stack (les N
représentent les NOP) : bottom of
DDDDDDDDEEEEEEEEEEEE EEEE FFFF FFFF FFFF FFFF top of <------
[NNNNNNNNNNNSSSSSSSSS][0xDE][0xDE][0xDE][0xDE][0xDE] On écris donc un nouvel
exploit exploit3.c #include
<stdlib.h> #define
DEFAULT_OFFSET 0 char
shellcode[]
= unsigned long
get_sp(void) { void main(int
argc, char *argv[]) { if (argc >
1) bsize = atoi(argv[1]); if (!(buff =
malloc(bsize))) { addr =
get_sp() - offset; ptr =
buff; for (i = 0; i
< bsize/2; i++) ptr = buff +
((bsize/2) - (strlen(shellcode)/2)); buff[bsize -
1] = '\0'; memcpy(buff,"EGG=",4); Une bonne taille pour notre buffer est de 100 bytes
de plus que le buffer sur lequel on tente l'overflow. Cela place notre
buffer à la fin du buffer que l'on veut "overflower" laissant beaucoup de
place aux NOP mais réécrivant toujours sur l'adresse de retour vec
l'adresse que l'on veut. Le buffer que l'on essayer "d'overflower" est
long de 512 bytes, donc on va utiliser 612. On va donc essayer notre
programme de test avec ce nouvel exploit. Using address: 0xbffffdb4 [aleph1]$ ./vulnerable $EGG $ |
> Razor Advisories CAN 2000-1039
> CERT
Advisories CA-2000-21
Vue d'ensembleUne nouvelle catégorie de vulnérabilités DoS a été découverte, et Naphta est le nom générique la désignant. Les vulnérabilités proviennent de faiblesses dans la manière dont la pile TCP/IP et les applications réseau manipulent l'état d'une connexion TCP. Systèmes Affectés Une forte proportion des applications et systèmes réseau
Notes Compaq - Tru64 UNIX V4.0F |